/**
 * 
 */
package com.emovimento.synchronize;

import java.io.File;
import java.io.IOException;
import java.nio.file.FileSystemException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Collection;
import java.util.Date;
import java.util.logging.Logger;
import java.util.prefs.Preferences;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.collections4.ListUtils;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang.StringUtils;

/**
 * Synchronization of 2 directories: - new files are copied vice versa - deleted files are deleted vice versa - updated files are updated
 * vice versa a file is new, if it a) doesn't exist in other directory and b) has file attributes newer than last run a file is deleted, if
 * it a) doesn't exist in other directory and b) has file attributes older than last run a file is updated, if it a) exists with same name
 * in same subdir of other directory and b) has newer file attributes --> files that are new on both sides are treated as updated files
 * (i.e. the newer version wins)
 */
public class Synchronizer {
  private static Logger logger = Logger.getLogger(Synchronizer.class.getName());

  private static Preferences prefs = Preferences.userRoot().node("/com/emovimento/synchronize");
  private long lastRunSourceTime;
  private long lastRunTargetTime;

  private long getLastRunSourceTime(SynchronizeFile sourceDir, SynchronizeFile targetDir) {
    if (lastRunSourceTime == 0) {
      lastRunSourceTime = prefs.getLong("last src time " + getPreferencesKeyToDirs(sourceDir, targetDir), 0);
    }
    return lastRunSourceTime;
  }

  private long getLastRunTargetTime(SynchronizeFile sourceDir, SynchronizeFile targetDir) {
    if (lastRunTargetTime == 0) {
      lastRunTargetTime = prefs.getLong("last dest time " + getPreferencesKeyToDirs(sourceDir, targetDir), 0);
    }
    return lastRunTargetTime;
  }

  /**
   * Saves the current time as last run time for the given directories in preferences
   * 
   * @param sourceDir the source-dir to save the last runtime for
   * @param targetDir the target-dir to save the last runtime for
   */
  private void saveRunTimes(SynchronizeFile sourceDir, SynchronizeFile targetDir) {
    prefs.putLong("last src time " + getPreferencesKeyToDirs(sourceDir, targetDir), getCurrentFileSystemTime(sourceDir));
    prefs.putLong("last dest time " + getPreferencesKeyToDirs(sourceDir, targetDir), getCurrentFileSystemTime(targetDir));
  }

  private String getPreferencesKeyToDirs(SynchronizeFile sourceDir, SynchronizeFile targetDir) {
    return StringUtils.right(sourceDir.getAbsolutePath(), 30) + " <-> " + StringUtils.right(targetDir.getAbsolutePath(), 30);
  }

  private void run(SynchronizeFile sourceDir, SynchronizeFile targetDir) throws IOException {
    long systemTimeDifference = Math.abs(getLastRunSourceTime(sourceDir, targetDir) - getLastRunTargetTime(sourceDir, targetDir));
    if (systemTimeDifference > 10) {
      logger.warning("Warning: Source and target file-system-time differ " +
        systemTimeDifference +
        "ms --> Synchronization might not work 100% correctly");
    } else if (systemTimeDifference > 1000 * 60 * 30) {
      logger.severe("Error: Source and target file-system-time differ " +
        systemTimeDifference +
        "ms --> Synchronization will propably not work correctly");
    }

    this.waitToReRun(sourceDir, targetDir);

    logger.info("Synchronizing " + sourceDir.getAbsolutePath() + " with " + targetDir.getAbsolutePath());
    this.synchronize(sourceDir, targetDir);
    logger.info("Synchronizing done.");

    saveRunTimes(sourceDir, targetDir);

    this.lastRunSourceTime = 0;
    this.lastRunTargetTime = 0;
  }

  /**
   * @param args
   * @throws IOException
   */
  public static void main(String[] args) throws IOException {
    if (args.length != 2) {
      System.err.println("Usage: synchronize <source dir> <target dir>");
      System.exit( -1);
    }

    SynchronizeFile sourceDir = new SynchronizeFile(args[0]);
    SynchronizeFile targetDir = new SynchronizeFile(args[1]);

    if ( !sourceDir.exists()) {
      System.err.println("Error: source dir '" + sourceDir.getPath() + "' does not exist!");
      System.exit( -1);
    }

    if ( !targetDir.exists()) {
      System.err.println("Error: target dir '" + targetDir.getPath() + "' does not exist!");
      System.exit( -1);
    }

    if ( !sourceDir.isDirectory()) {
      System.err.println("Error: source dir '" + sourceDir.getPath() + "' is no directory!");
      System.exit( -1);
    }

    if ( !targetDir.isDirectory()) {
      System.err.println("Error: target dir '" + targetDir.getPath() + "' is no directory!");
      System.exit( -1);
    }

    new Synchronizer().run(sourceDir, targetDir);
  }

  /**
   * Waits until enough time has passed until we can do a re-run of the synchronization
   * 
   * @throws InterruptedException
   */
  private synchronized void waitToReRun(SynchronizeFile sourceDir, SynchronizeFile targetDir) {
    while (getCurrentFileSystemTime(sourceDir) == getLastRunSourceTime(sourceDir, targetDir) ||
      getCurrentFileSystemTime(targetDir) == getLastRunTargetTime(sourceDir, targetDir)) {
      try {
        wait(1000);
      } catch (InterruptedException e) {
        continue; // keep on waiting until a minimum amount of time has passed
      }
    }

  }

  private void synchronize(SynchronizeFile sourceDir, SynchronizeFile targetDir) throws IOException {
    Collection<SynchronizeFile> sourceFiles = sourceDir.getSynchronizeFiles();
    Collection<SynchronizeFile> targetFiles = targetDir.getSynchronizeFiles();

    synchronizeMatching(sourceDir, CollectionUtils.retainAll(targetFiles, sourceFiles));

    synchronizeNewOrDeleted(targetDir, ListUtils.removeAll(sourceFiles, targetFiles), getLastRunSourceTime(sourceDir, targetDir));
    synchronizeNewOrDeleted(sourceDir, ListUtils.removeAll(targetFiles, sourceFiles), getLastRunTargetTime(sourceDir, targetDir));
  }

  /**
   * Gets the current file system time of the filesystem the given directory resides in. If this cannot be retreived the current time is
   * returned
   * 
   * @param directory the directory of the filesystem to get the current time from
   * @return the filesystem time or if it cannot be retreived the current time
   */
  private long getCurrentFileSystemTime(SynchronizeFile dir) {
    try {
      File tmpFile = File.createTempFile("time", null, dir);
      long lastModified = tmpFile.lastModified();
      tmpFile.delete();

      return lastModified;
    } catch (IOException e) {
      return new Date().getTime();
    }
  }

  /**
   * Returns the file matching the given filename in the given directory. Returns null if no such file was found.
   * 
   * @param directory the directory to search the file in
   * @param filename the filename to search for
   * @return the found file or null
   */
  // TODO make this private and non-static (need to use PrivilegedAccessor for tests)
  /* package */static SynchronizeFile findFile(SynchronizeFile directory, String filename) {
    for (SynchronizeFile file : directory.getSynchronizeFiles()) {
      if (file.getName().equals(filename)) { return file; }
    }
    return null;
  }

  private void synchronizeMatching(SynchronizeFile sourceDir, Collection<SynchronizeFile> matchingTargetFiles) throws IOException {
    if (matchingTargetFiles.size() == 0) return;

    for (SynchronizeFile target : matchingTargetFiles) {
      SynchronizeFile source = findFile(sourceDir, target.getName());
      if (source.isDirectory() && target.isDirectory()) {
        synchronize(source, target);
        continue;
      }
      // TODO add exceptional behavior if source isDirectory and target is not (and vice versa)
      BasicFileAttributes sourceAttributes = Files.readAttributes(source.toPath(), BasicFileAttributes.class);
      BasicFileAttributes targetAttributes = Files.readAttributes(target.toPath(), BasicFileAttributes.class);

      if (sourceAttributes.lastModifiedTime().equals(targetAttributes.lastModifiedTime()) &&
        sourceAttributes.creationTime().equals(targetAttributes.creationTime())) {
        continue; // do nothing when the files have the same age
      }
      if (source.lastModified() > target.lastModified()) {
        logger.info("Overwriting " + target.getAbsolutePath() + " with " + source.getAbsolutePath());

        try {
          Files.copy(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);
          Files.setAttribute(target.toPath(), "creationTime", sourceAttributes.creationTime());
        } catch (FileSystemException e) {
          logger.warning(e.toString());
        }
      }
      if (source.lastModified() < target.lastModified()) {
        logger.info("Overwriting " + source.getPath() + " with " + target.getAbsolutePath());

        try {
          Files.copy(target.toPath(), source.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);
          Files.setAttribute(source.toPath(), "creationTime", targetAttributes.creationTime());
        } catch (FileSystemException e) {
          logger.warning(e.toString());
        }

      }
    }
  }

  private void synchronizeNewOrDeleted(SynchronizeFile targetDir, Collection<SynchronizeFile> newOrDeletedFiles, long lastRunTime)
      throws IOException {
    if (newOrDeletedFiles.size() == 0) return;

    for (File currentFile : newOrDeletedFiles) {
      BasicFileAttributes attributes = Files.readAttributes(currentFile.toPath(), BasicFileAttributes.class);

      if (attributes.lastModifiedTime().toMillis() >= lastRunTime || attributes.creationTime().toMillis() >= lastRunTime) {
        logger.info("Adding " + currentFile.getPath() + " to " + targetDir.getAbsolutePath());
        if (currentFile.isDirectory()) {
          FileUtils.copyDirectory(currentFile, new File(targetDir, currentFile.getName()));
        } else {
          FileUtils.copyFile(currentFile, new File(targetDir, currentFile.getName()));
          try {
            Files.copy(
              currentFile.toPath(),
              new File(targetDir, currentFile.getName()).toPath(),
              StandardCopyOption.REPLACE_EXISTING,
              StandardCopyOption.COPY_ATTRIBUTES);
            Files.setAttribute(new File(targetDir, currentFile.getName()).toPath(), "creationTime", attributes.creationTime());
          } catch (FileSystemException e) {
            logger.warning(e.toString());
          }
        }
      } else {
        logger.info("Deleting " + currentFile.getName() + " from " + currentFile.getParent());
        if (currentFile.isDirectory()) {
          // TODO move to recycle bin (since it could be that we are deleting a dir with newly changed/added files)
          FileUtils.deleteDirectory(currentFile); // deletes also non-empty directories
        } else {
          currentFile.delete();
        }
      }
    }
  }

}
